/*
 * Document.java
 *
 * Created on October 29, 2006, 6:30 PM
 *
 * To change this template, choose Tools | Template Manager
 * and open the template in the editor.
 */

package edu.umn.cs5115.scheduler.framework;

import java.io.File;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.ControlEvent;
import org.eclipse.swt.events.ControlListener;
import org.eclipse.swt.events.DisposeEvent;
import org.eclipse.swt.events.DisposeListener;
import org.eclipse.swt.widgets.FileDialog;
import org.eclipse.swt.widgets.MessageBox;
import org.eclipse.swt.widgets.Shell;

import edu.umn.cs5115.scheduler.framework.gui.SavePromptDialog;

/**
 * A document represents one document that the user interacts with.
 * @author grant
 */
public abstract class Document extends KeyValueCodingBase
{
    /** Key that notifications will be sent on when document's saveable state changes. */
    public final static String SAVABLE = "savable";
    /** Key that notifications will be sent on when document's revertable state changes. */
    public final static String REVERTABLE = "revertable";
    /** Key for the document's display name (usualy "Untitled" or the name of the document.) */
    public final static String DISPLAY_NAME = "displayName";
    
    /** UndoManager for the document that keeps track of all undoable actions. */
    private UndoManager undoManager = new UndoManager();
    /** State of the undo stack when the document was last saved. */
    private int undoStackState = 0;
    /** Name of the document for display in window title bars. */
    private String displayName = "Untitled";
    /** List of window controllers open for this document. */
    private ArrayList windowControllers = new ArrayList();
    /** Whether or not the document is already closing.  This allows us to 
     * instantly allow a window to close, without checking. */
    private boolean closing;
    /** File that we were last saved to, and will save to again. */
    private URL fileURL;
    /** Last time we checked, did we have unsaved changes? Used to send notifications at appropriate times. */
    private boolean wasSavable = true;
    /** Last time we checked, were we revertable? Used to send notifications at appropriate times. */
    private boolean wasRevertable = false;
    /** Undo Listener, listens to undo changes and calls updateChangesAndRevertable() */
    private KeyValueObserver undoListener;
    /** A document is pristine only if it has NEVER had any changes made, and if
     * they were not explicitly created by the user.  (it was opened 
     * automatically) If the user made changes, then did undo, it is not 
     * pristine.  If the user explicitly creates a new document, all open 
     * documents are no longer pristine.
     * Pristine documents are automatically closed when the user opens a 
     * document. */
    private boolean pristine;
    /** listens for changes on the undo stack to mark the document as not being pristine. */
    private KeyValueObserver pristineUndoListener = new KeyValueAdapter()
    {
        public void valueChanged(KeyValueCoding object, String key)
        {
            if (undoManager.canUndo())
                setPristine(false);
        }
    };
    /** listens for changes in the document's window's frame's to mark the 
     * document as not being pristine. */
    private ControlListener windowMovementListener = new ControlListener()
    {
        public void controlMoved(ControlEvent e)
        {
            setPristine(false);
        }
        public void controlResized(ControlEvent e)
        {
            setPristine(false);
        }
    };

    /** Set to true when the document closes. */
    private boolean closed;
    
    /** Creates a new instance of Document */
    public Document()
    {
        undoListener = new KeyValueAdapter()
        {
            public void valueChanged(KeyValueCoding object, String key)
            {
                updateChangesAndRevertable();
            }
        };
        undoManager.addListener(undoListener, UndoManager.UNDO_KEY);
    }
    
    /** 
     * Initialize a document by reading from the specified URL.  If you override
     * this method to do custom reading, you may assume that this will be called
     * immediately after the constructor returns and before 
     * createWindowControllers() has been called.
     * @param fileURL A URL for the file to read from.
     */
    public boolean readFromURL(URL fileURL)
    {
        setFileURL(fileURL);
        return true;
    }
    
    /**
     * Subclasses should implement this method to set up initial window 
     * controllers for a document.  Each window controller should be added to 
     * the document using addWindowController().
     * @see #addWindowController(WindowController)
     */
    protected abstract void createWindowControllers();
    
    /**
     * Adds a window controller to the list of windows managed by this document.
     * @param windowController The window controller to add.
     */
    public void addWindowController(WindowController windowController)
    {
        windowControllers.add(windowController);
    }

    /**
     * Returns the document's display name, which is usually just the name of 
     * the open file, or "Untitled" if it hasn't been saved yet.
     * @return The document's display name.
     */
    public String getDisplayName()
    {
        return displayName;
    }
    
    /**
     * Removes a window controller from the list of windows managed by this 
     * document.
     * @param windowController The window controller to remove.
     */
    public void removeWindowController(WindowController windowController)
    {
        windowControllers.remove(windowController);
    }

    public UndoManager getUndoManager()
    {
        return undoManager;
    }
    
    private void updateChangesAndRevertable()
    {
        boolean hasUnsavedChanges = (fileURL == null) || hasUnsavedChanges();
        boolean isRevertable = hasUnsavedChanges && (fileURL != null);
        if (hasUnsavedChanges != wasSavable)
        {
            wasSavable = hasUnsavedChanges;
            didChangeValueForKey(SAVABLE);
        }
        if (isRevertable != wasRevertable)
        {
            wasRevertable = isRevertable;
            didChangeValueForKey(REVERTABLE);
        }
    }
    
    /**
     * Check to see if the document is savable.  This means either it has 
     * unsaved changes, or it has not yet been saved.
     * @return True if the document is savable, false otherwise.
     */
    public boolean isSavable()
    {
        return wasSavable;
    }
    
    /**
     * Find out if the document has unsaved changes. (and thus, we should prompt
     * the user before closing it to save it.)  The default implementation works
     * by testing to see if the undo manager is at the state it was in when the
     * document was last saved.
     * Note that it still makes sense to save documents without unsaved changes
     * if they have never been saved, even if they don't have unsaved changes.
     * @return True if the document has unsaved changes, false otherwise.
     * @see #isSavable()
     */
    public boolean hasUnsavedChanges()
    {
        return (undoManager.getUndoStackState() != undoStackState);
    }
    
    /**
     * Find out if the document has been saved, then modified, and would thus be
     * revertable.
     * @return True if "Revert" makes sense, false otherwise.
     */
    public boolean isRevertable()
    {
        return wasRevertable;
    }
    
    /** Save the document.  This saves it to the last location it was saved in.
     * If the document hasn't been saved yet, the user is prompted to select a
     * location to save it.
     * @return True if the save operation is successfully completed, false 
     * otherwise (if the user cancels or an error occurs)
     */
    public boolean save()
    {
        if (fileURL != null)
            return doSave(fileURL);
        else
            return saveAs();
    }
    
    /** Prompts the user for location to save the document as and saves it.
     * @return True if the saveAs is successful, false otherwise (if there is
     * an error or if the user cancels the operation)
     */
    public boolean saveAs()
    {
        Shell window = getMainWindow();
        if (window == null)
            window = DocumentController.getSharedDocumentController().getAnyShell();
        FileDialog dialog = new FileDialog(window, SWT.SAVE);
        String filePath = dialog.open();
        try
        {
            if (filePath != null)
            {
                URL newURL = new File(filePath).toURI().toURL();
                return doSave(newURL);
            }
            return false;
        } catch (MalformedURLException ex) {
            ex.printStackTrace();
            displayDocumentError("Your system does not support file URLs.  The application cannot function without this support.");
            return false;
        }
    }
    
    /**
     * Save the file.  This will call writeToURL, and update the undoStackState.
     * @return True if the save is successful, false otherwise
     */
    private boolean doSave(URL fileURL)
    {
        if (writeToURL(fileURL))
        {
            undoStackState = undoManager.getUndoStackState();
            this.setFileURL(fileURL);
            updateChangesAndRevertable();
            return true;
        }
        return false;
    }
    
    /**
     * Save the file to the given URL.
     * @param fileURL The url to write the file's data to.
     * @return True if the operation is succesful, false otherwise.
     */
    protected boolean writeToURL(URL fileURL)
    {
        return true;
    }
    
    /** Closes the document, prompting to save unsaved changes if necessary.
     * @return True if the operation is successful, false if it fails (due to
     * the user canceling the operation)
     */
    public boolean close()
    {
        if (!closed)
        {
            if (hasUnsavedChanges())
            {
                // prompt the user to save here...
                SavePromptDialog savePrompt = new SavePromptDialog(getMainWindow());
                int result = savePrompt.promptToSave(getDisplayName());
                if (result == SavePromptDialog.CANCEL)
                    return false;
                else if (result == SavePromptDialog.SAVE){
                    if (!save())
                        return false;
                } // else // result == SavePromptDialog.DONT_SAVE
            }
            closeWindows();
            undoManager.removeListener(undoListener);
            // remove this from the list of open documents
            closed = true;
            DocumentController.getSharedDocumentController().documentDidClose(this);
        }
        return true;
    }
    
    /**
     * Reverts to the last saved version of the file.  This is actually accomplished
     * by reopening the document and closing this one.
     */
    public void revert()
    {
        if (wasRevertable)
        {
            closeWindows();
            DocumentController.getSharedDocumentController().open(fileURL);
        }
    }
    
    private void closeWindows()
    {
        closing = true;
        // controllers get removed from the list as they close, so we need a current copy
        Collection currentWindowControllers = new ArrayList(windowControllers);
        Iterator iterator = currentWindowControllers.iterator();
        while(iterator.hasNext())
        {
            WindowController thisController = (WindowController) iterator.next();
            thisController.getWindow().close();
        }
    }
    
    /**
     * Strips down a path so that it includes only the last path component.
     * Since we work with URLs, this assumes a path separator of "/".
     * @param The full path
     * @return The string following the last "/" character in path, or path 
     * itself if no "/" was found.
     */
    private String lastPathComponent(String path)
    {
        int foundIndex = path.lastIndexOf('/');
        if (foundIndex < 0)
            return path;
        else
            return path.substring(foundIndex+1);
    }
    
    /**
     * Sets the file's URL, and update's the document's display name to match.
     * @param fileURL The new file URL.
     */
    private void setFileURL(URL fileURL)
    {
        this.fileURL = fileURL;
        URI fileURI;
        try
        {
            fileURI = fileURL.toURI();
            // There is a bug:  This doesn't work if there is a "/" character
            // in the file name.
            displayName = lastPathComponent(fileURI.getPath());
        } catch (URISyntaxException ex) {
            // This shouldn't happen...
            System.err.println("Couldn't convert a file URL to a URI?");
            ex.printStackTrace();
            //fall back on the URL
            displayName = lastPathComponent(fileURL.getFile());
        }
        didChangeValueForKey(DISPLAY_NAME);
    }
    
    /** Returns a name for this document, suitable for display in window title
     * bars, etc.
     * @return The name of the document. (for display)
     */
    public String displayName()
    {
        return displayName;
    }

    /** Explicitly modify the display name for the document.  You should 
     * normally not call this method, and just let the Document architecture 
     * handle this.
     * @param displayName The new display name for the document.  This is used
     * for window title bars, etc.
     */
    void setDisplayName(String displayName)
    {
        this.displayName = displayName;
        didChangeValueForKey(DISPLAY_NAME);
    }

    /** 
     * Tests whether or not the specified window controller can be closed.
     * If this is the last open window, or if closing this window will close the
     * document, calling this method may present the user with a save dialog if
     * the document has unsaved changes.  If the user cancels that dialog, this
     * method will return flase.  In all other cases, it returns true.
     */
    public boolean shouldCloseWindowController(WindowController windowController)
    {
        if (closing)
            return true;
        if (windowControllers.size() == 1 || windowController.getShouldCloseDocument())
        {
            return close();
        }
        return true;
    }
    
    /**
     * Gets a window in the document.
     * @return A window from the document. If the document is in the foreground, 
     * this is the active window.  If the document is not in the foreground, it
     * is an arbitrary window in the document.  If the document has no windows,
     * it returns null. 
     */
    public Shell getMainWindow()
    {
        DocumentController documentController = DocumentController.getSharedDocumentController();
        if (documentController.getActiveDocument() == this)
            return documentController.getDisplay().getActiveShell();
        else if (!windowControllers.isEmpty())
            return ((WindowController)windowControllers.get(0)).getWindow();
        else
            return null;
    }
    
    /**
     * Display a quick document-level error.  Avoid calling this function
     * if at all possible, of course.
     * @param errorText The error message to display.
     */
    public void displayDocumentError(String errorText)
    {
        Shell window = getMainWindow();
        if (window != null)
        {
            MessageBox error = new MessageBox(window, SWT.ICON_ERROR | SWT.OK);
            error.setMessage(errorText);
            error.open();
        } else {
            DocumentController.getSharedDocumentController().displayApplicationError(errorText);
        }
    }

    /** 
     * A document is pristine only if it has NEVER had any changes made, and if
     * they were not explicitly created by the user.  (it was opened 
     * automatically) If the user made changes, then did undo, it is not 
     * pristine.  If the user explicitly creates a new document, all open 
     * documents are no longer pristine.
     * Pristine documents are automatically closed when the user opens a 
     * document. 
     * @return True if the document is pristine, false if it was not marked as
     * pristine due to being opened automatically, has since been modified,
     * or if the user has made changes to it.
     * @see #setPristine(boolean)
     */
    public boolean isPristine()
    {
        return pristine;
    }

    /**
     * Marks a document as being pristine or not.  The DocumentController uses
     * this method to mark documents as pristine if they are automatically 
     * created and to mark them as not being pristine if they are modified.
     * @param pristine True to mark the document as pristine, and that it should
     * be closed
     */
    public void setPristine(boolean pristine)
    {
        this.pristine = pristine && isPristineBehaviorSupported();
        if (pristine) // add new listeners to undo and the windows' sizes.
        {
            Iterator iterator = windowControllers.iterator();
            while(iterator.hasNext())
            {
                WindowController thisController = (WindowController) iterator.next();
                Shell thisWindow = thisController.getWindow();
                if (thisWindow != null);
                    thisWindow.addControlListener(windowMovementListener);
            }
            undoManager.addListener(pristineUndoListener, UndoManager.UNDO_KEY);
        } else { // remove listeners
            Iterator iterator = windowControllers.iterator();
            while(iterator.hasNext())
            {
                WindowController thisController = (WindowController) iterator.next();
                Shell thisWindow = thisController.getWindow();
                if (thisWindow != null)
                    thisWindow.removeControlListener(windowMovementListener);
            }
            undoManager.removeListener(pristineUndoListener, UndoManager.UNDO_KEY);
        }
    }
    
    /**
     * Pristine behavior means that opening a file when there's a new document
     * open that was automatically created will result in the document being
     * opened inside the existing, empty document.
     * @return True if this document supports the pristine behavior, false 
     * otherwise.
     * @see #isPristine()
     */
    public boolean isPristineBehaviorSupported()
    {
        return false;
    }
}
